Skip to content

feat(emoji): skin-tone selector + grid scroll-cancellation in library sheet - #345

Open
dmnyc wants to merge 2 commits into
mainfrom
feat/emoji-skin-tone-selector
Open

dmnyc wants to merge 2 commits into
mainfrom
feat/emoji-skin-tone-selector

Conversation

@dmnyc

@dmnyc dmnyc commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two related changes to the full emoji library sheet — both shippable independently of the catalog generator and the "More reactions" hit-target work.

Skin-tone selector: a single horizontal row of 6 swatches (default + 5 Fitzpatrick tones) sits between the search field and the tab strip. Tapping a swatch persists the choice in UserDefaults via EmojiSkinTonePreference and every tone-capable cell in the grid re-renders in that tone immediately. The mechanism matches what other clients do — a single global preference rather than per-pick popovers, which conflicted with Button's tap recognizer when tried first.

Grid scroll-cancellation: the emoji and custom-image cell wrappers now use .contentShape(Rectangle()).onTapGesture instead of Button { } .buttonStyle(.plain). Button inside a LazyVGrid inside a ScrollView only cancels its tap on substantial finger movement, so a quick flick to scroll the long grid was registering as a pick on release. .onTapGesture cancels on any drag past the hit-test threshold, which is what the user expects for a scroll.

SkinTone.swift introduces:

  • EmojiSkinTone enum mapping each tone to its Fitzpatrick modifier codepoint, with a preview-swatch helper.
  • EmojiSkinTonePreference wrapper around UserDefaults that posts a notification when the value changes so live sheets refresh.
  • String.applyingSkinTone(_:) and removingSkinTones() helpers that handle both plain base emoji (append modifier) and single-human-glyph ZWJ sequences (insert modifier before the first ZWJ, with VS-16 awareness). Multi-person sequences are intentionally out of scope.
  • EmojiToneCapability — curated Set<String> of tone-capable base emoji. Membership check strips VS-16 so "👋" and "👋\u{FE0F}" both match.

Files

  • SkinTone.swift — new, ~135 lines
  • EmojiLibrarySheet.swift — skinToneSelector row, toneAwareCell rendering, preferredTone state + notification observer; Button → .onTapGesture swap on both grids
  • wisp.xcodeproj/project.pbxproj — registers SkinTone.swift

Test plan

  • Library sheet shows the "Skin tone" row at the top with 6 swatches, active one outlined
  • Tap the dark swatch → every hand / body / person cell re-renders in dark tone
  • Dismiss + re-open → dark swatch still selected (persisted)
  • Pick a toned 👍 → reaction sent with the tone applied
  • Long-press grid + drag → scrolls cleanly, no accidental pick on release
  • Build clean for iOS Simulator

Closes #304.

… sheet

Two related changes to the full emoji library sheet — both shippable
independently of the catalog generator and the "More reactions" hit
target work.

Skin-tone selector: a single horizontal row of 6 swatches (default + 5
Fitzpatrick tones) sits between the search field and the tab strip.
Tapping a swatch persists the choice in `UserDefaults` via
`EmojiSkinTonePreference` and every tone-capable cell in the grid
re-renders in that tone immediately. The mechanism matches what other
clients do — a single global preference rather than per-pick popovers,
which conflicted with `Button`'s tap recognizer when tried first.

`SkinTone.swift` introduces:
- `EmojiSkinTone` enum mapping each tone to its Fitzpatrick modifier
  codepoint, with a preview-swatch helper.
- `EmojiSkinTonePreference` wrapper around `UserDefaults` that posts a
  notification when the value changes so live sheets refresh.
- `String.applyingSkinTone(_:)` and `removingSkinTones()` helpers that
  handle both plain base emoji (append modifier) and single-human-glyph
  ZWJ sequences (insert modifier before the first ZWJ, with VS-16
  awareness). Multi-person sequences are intentionally out of scope.
- `EmojiToneCapability` — curated `Set<String>` of tone-capable base
  emoji. Membership check strips VS-16 so "👋" and "👋\u{FE0F}" both
  match.

Grid scroll-cancellation: the emoji + custom-image cell wrappers now
use `.contentShape(Rectangle()).onTapGesture` instead of `Button { }
.buttonStyle(.plain)`. `Button` inside a `LazyVGrid` inside a
`ScrollView` only cancels its tap on substantial finger movement, so a
quick flick to scroll the long grid was registering as a pick on
release. `.onTapGesture` cancels on any drag past the hit-test
threshold, which is what the user expects for a scroll.

Closes #304.
@barrydeen

Copy link
Copy Markdown
Owner

Thank you for the suggestion! We do not accept feature requests via issues — please submit a pull request instead. Closing.

@barrydeen barrydeen closed this Aug 10, 2026
@dmnyc dmnyc reopened this Aug 11, 2026
pbxproj conflicts were registration-line collisions only (SkinTone.swift
vs WispTopHeader/AccountSwitcherSheet added in the same spots); kept both.

@barrydeen barrydeen left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: #345 — feat(emoji): skin-tone picker + tap-fix in emoji library

Verdict: CHANGES REQUESTED
Risk areas touched: user-visible picker UX, accessibility, search/compose input path (toned emoji now enter note content), build (new root file + pbxproj)
Scope check: Files match the description — SkinTone.swift (new, 174 lines), EmojiLibrarySheet.swift (grid rework + tone row), project.pbxproj (+4).
Build/tests: no Swift toolchain on this machine and no CI checks on the branch, so this review is static; recommend one simulator pass of the library sheet before merge.

Findings

[MEDIUM] Emoji grid cells lost VoiceOver activation when Button was replaced by Text + onTapGesture

  • Where: EmojiLibrarySheet.swift (grid cell: Text(unicode).contentShape(Rectangle()).onTapGesture { handleUnicodePick(rendered) })
  • What: The old cells were Buttons, which VoiceOver activates natively (double-tap). Plain Text with .onTapGesture exposes no activation action, so the grid's core function is unreachable with VoiceOver on — a regression introduced while fixing the scroll misfire (#304).
  • Impact / trigger: VoiceOver users can no longer insert emoji from the library sheet at all.
  • Fix: keep the tap gesture and add back the semantics: .accessibilityLabel(name).accessibilityAddTraits(.isButton).accessibilityAction { handleUnicodePick(rendered) }. The misfire bug was the Button reacting to a tap that the scroll should have cancelled; a gesture-driven action with explicit a11y traits keeps the fix intact.

Questions for the author

  • SkinTone.swift EmojiToneCapability — 🤳 (selfie) is tone-capable per Unicode but I don't see it in the base set; and the ZWJ insertion path only meaningfully fires for single-codepoint human bases (multi-person/role sequences like 👨‍👩‍👧 or 👨‍🚀 aren't in the capability set, so the branch is close to dead code today). Intentional curation, or oversight? If intentional, worth a one-line comment so the next person doesn't "fix" it.
  • The preferred tone is one global UserDefaults key (com.wisp.emoji.preferredSkinTone), not per-account. Fine for a UI pref — just confirming that's deliberate given account switching elsewhere is per-pubkey.

Nits

  • SkinTone.swift EmojiSkinToneRow — the five swatch buttons have no .accessibilityLabel (VoiceOver announces them as bare "👋, button" ×5 with no way to tell tones apart), and the preview glyph is 👋 for every tone. Per-tone labels ("Medium-light skin tone") would fix both complaints cheaply.
  • SkinTone.swift — String.applyingSkinTone re-appends trailing VS-16 after inserting the modifier; correct per UTS #51, but 👋+🏽 vs ⛹+🏾 exercise both branches — worth 2 Swift Testing cases in wispTests since this is pure logic and CI-runnable.

Checked and OK

  • SkinTone.swift registered correctly in project.pbxproj: exactly one PBXFileReference, one PBXBuildFile, added to the app target's Sources phase only — matches how the other root-level view files (EmojiLibrarySheet.swift, EmojiData.swift) are wired; the test/UITests/ShareExtension targets don't see it, consistent with their existing contents.
  • Tone-modifier codepoints are U+1F3FB–U+1F3FF, mapping order default→light→…→dark is correct, and the default swatch sends the unmodified base.
  • Capability check strips VS-16 before lookup, so VS-16-required bases (⛹, 🏃) resolve correctly, and applyingSkinTone re-adds VS-16 for those — the qualified/neutral-form distinction is handled per UTS #51.
  • ZWJ insertion goes after the first human sequence element, not appended blindly — safe for the single-human cases that are actually in the capability set.
  • Preference writes go through one UserDefaults key and post .emojiSkinTonePreferenceDidChange; the sheet's onReceive refreshes @State so a tone change in Settings reflects in an open sheet — no stale-state path I could find.
  • Search path also routes through the tone-aware cell, so query results insert toned emoji consistently with browse results; handleUnicodePick(rendered) still feeds the same insertion path as before (toned emoji were already typeable via the system keyboard / possible via Amethyst reactions, so no new relay-compat surface).
  • The tap fix itself is sound: onTapGesture is cancelled by the enclosing ScrollView's drag, which is precisely the #304 misfire behavior the Button style couldn't get.
  • No keys/crypto/relay/storage-schema surfaces touched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(emoji): skin tone and hair variants via long-press on emoji cells

2 participants